// Copyright (C) 2013 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.gerrit.acceptance.api.revision;

import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth8.assertThat;
import static com.google.gerrit.acceptance.PushOneCommit.FILE_CONTENT;
import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
import static com.google.gerrit.acceptance.PushOneCommit.PATCH;
import static com.google.gerrit.acceptance.PushOneCommit.PATCH_FILE_ONLY;
import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
import static com.google.gerrit.entities.Patch.COMMIT_MSG;
import static com.google.gerrit.entities.Patch.MERGE_LIST;
import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
import static com.google.gerrit.extensions.client.ListChangesOption.ALL_REVISIONS;
import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
import static com.google.gerrit.git.ObjectIds.abbreviateName;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.toList;
import static org.eclipse.jgit.lib.Constants.HEAD;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Iterators;
import com.google.common.collect.ListMultimap;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.ExtensionRegistry;
import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.RestResponse;
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.TestProjectInput;
import com.google.gerrit.acceptance.UseClockStep;
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.BranchOrderSection;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.LabelId;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Permission;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.extensions.api.changes.ChangeApi;
import com.google.gerrit.extensions.api.changes.CherryPickInput;
import com.google.gerrit.extensions.api.changes.DraftApi;
import com.google.gerrit.extensions.api.changes.DraftInput;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.NotifyInfo;
import com.google.gerrit.extensions.api.changes.RecipientType;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
import com.google.gerrit.extensions.api.changes.RevisionApi;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.api.projects.ConfigInput;
import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gerrit.extensions.client.ListChangesOption;
import com.google.gerrit.extensions.client.ReviewerState;
import com.google.gerrit.extensions.client.SubmitType;
import com.google.gerrit.extensions.common.AccountInfo;
import com.google.gerrit.extensions.common.ApprovalInfo;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.ChangeMessageInfo;
import com.google.gerrit.extensions.common.CommentInfo;
import com.google.gerrit.extensions.common.CommitInfo;
import com.google.gerrit.extensions.common.FileInfo;
import com.google.gerrit.extensions.common.GitPerson;
import com.google.gerrit.extensions.common.LabelInfo;
import com.google.gerrit.extensions.common.MergeableInfo;
import com.google.gerrit.extensions.common.RevisionInfo;
import com.google.gerrit.extensions.common.RevisionInfo.ParentInfo;
import com.google.gerrit.extensions.common.WebLinkInfo;
import com.google.gerrit.extensions.events.ChangeIndexedListener;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.BinaryResult;
import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.extensions.webui.PatchSetWebLink;
import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
import com.google.gerrit.server.events.CommitReceivedEvent;
import com.google.gerrit.server.git.validators.CommitValidationException;
import com.google.gerrit.server.git.validators.CommitValidationListener;
import com.google.gerrit.server.git.validators.CommitValidationMessage;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.util.AccountTemplateUtil;
import com.google.gerrit.testing.FakeEmailSender;
import com.google.inject.Inject;
import java.io.ByteArrayOutputStream;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.transport.RefSpec;
import org.junit.Test;

public class RevisionIT extends AbstractDaemonTest {
  @Inject private ProjectOperations projectOperations;
  @Inject private RequestScopeOperations requestScopeOperations;
  @Inject private ExtensionRegistry extensionRegistry;
  @Inject private AccountOperations accountOperations;
  @Inject private ChangeOperations changeOperations;

  @Test
  public void reviewTriplet() throws Exception {
    PushOneCommit.Result r = createChange();
    gApi.changes()
        .id(project.get() + "~master~" + r.getChangeId())
        .revision(r.getCommit().name())
        .review(ReviewInput.approve());
  }

  @Test
  public void reviewCurrent() throws Exception {
    PushOneCommit.Result r = createChange();
    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
  }

  @Test
  public void reviewNumber() throws Exception {
    PushOneCommit.Result r = createChange();
    gApi.changes().id(r.getChangeId()).revision(1).review(ReviewInput.approve());

    r = updateChange(r, "new content");
    gApi.changes().id(r.getChangeId()).revision(2).review(ReviewInput.approve());
  }

  @Test
  public void submit() throws Exception {
    PushOneCommit.Result r = createChange();
    String changeId = project.get() + "~master~" + r.getChangeId();
    gApi.changes().id(changeId).current().review(ReviewInput.approve());
    ChangeInfo changeInfo = gApi.changes().id(changeId).current().submit();
    assertThat(changeInfo.status).isEqualTo(ChangeStatus.MERGED);
    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
  }

  @Test
  public void postSubmitApproval() throws Exception {
    PushOneCommit.Result r = createChange();
    String changeId = project.get() + "~master~" + r.getChangeId();
    gApi.changes().id(changeId).current().review(ReviewInput.recommend());

    String label = LabelId.CODE_REVIEW;
    ApprovalInfo approval = getApproval(changeId, label);
    assertThat(approval.value).isEqualTo(1);
    assertThat(approval.postSubmit).isNull();

    // Submit by direct push.
    git().push().setRefSpecs(new RefSpec(r.getCommit().name() + ":refs/heads/master")).call();
    assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);

    approval = getApproval(changeId, label);
    assertThat(approval.value).isEqualTo(1);
    assertThat(approval.postSubmit).isNull();
    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), LabelId.CODE_REVIEW, 1, 2);

    // Repeating the current label is allowed. Does not flip the postSubmit bit due to
    // deduplication codepath.
    gApi.changes().id(changeId).current().review(ReviewInput.recommend());
    approval = getApproval(changeId, label);
    assertThat(approval.value).isEqualTo(1);
    assertThat(approval.postSubmit).isNull();

    // Reducing vote is not allowed.
    ResourceConflictException thrown =
        assertThrows(
            ResourceConflictException.class,
            () -> gApi.changes().id(changeId).current().review(ReviewInput.dislike()));
    assertThat(thrown)
        .hasMessageThat()
        .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
    approval = getApproval(changeId, label);
    assertThat(approval.value).isEqualTo(1);
    assertThat(approval.postSubmit).isNull();

    // Increasing vote is allowed.
    gApi.changes().id(changeId).current().review(ReviewInput.approve());
    approval = getApproval(changeId, label);
    assertThat(approval.value).isEqualTo(2);
    assertThat(approval.postSubmit).isTrue();
    assertPermitted(gApi.changes().id(changeId).get(DETAILED_LABELS), LabelId.CODE_REVIEW, 2);

    // Decreasing to previous post-submit vote is still not allowed.
    thrown =
        assertThrows(
            ResourceConflictException.class,
            () -> gApi.changes().id(changeId).current().review(ReviewInput.dislike()));
    assertThat(thrown)
        .hasMessageThat()
        .isEqualTo("Cannot reduce vote on labels for closed change: Code-Review");
    approval = getApproval(changeId, label);
    assertThat(approval.value).isEqualTo(2);
    assertThat(approval.postSubmit).isTrue();
  }

  @Test
  public void postSubmitApprovalAfterVoteRemoved() throws Exception {
    PushOneCommit.Result r = createChange();
    String changeId = project.get() + "~master~" + r.getChangeId();

    requestScopeOperations.setApiUser(admin.id());
    revision(r).review(ReviewInput.approve());

    requestScopeOperations.setApiUser(user.id());
    revision(r).review(ReviewInput.recommend());

    requestScopeOperations.setApiUser(admin.id());
    gApi.changes().id(changeId).reviewer(user.username()).deleteVote(LabelId.CODE_REVIEW);
    Optional<ApprovalInfo> crUser =
        get(changeId, DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all.stream()
            .filter(a -> a._accountId == user.id().get())
            .findFirst();
    assertThat(crUser).isPresent();
    assertThat(crUser.get().value).isEqualTo(0);

    revision(r).submit();

    requestScopeOperations.setApiUser(user.id());
    ReviewInput in = new ReviewInput();
    in.label(LabelId.CODE_REVIEW, 1);
    in.message = "Still LGTM";
    revision(r).review(in);

    ApprovalInfo cr =
        gApi.changes().id(changeId).get(DETAILED_LABELS).labels.get(LabelId.CODE_REVIEW).all
            .stream()
            .filter(a -> a._accountId == user.id().get())
            .findFirst()
            .get();
    assertThat(cr.postSubmit).isTrue();
  }

  @Test
  public void postSubmitDeleteApprovalNotAllowed() throws Exception {
    PushOneCommit.Result r = createChange();

    revision(r).review(ReviewInput.approve());
    revision(r).submit();

    ReviewInput in = new ReviewInput();
    in.label(LabelId.CODE_REVIEW, 0);

    ResourceConflictException thrown =
        assertThrows(ResourceConflictException.class, () -> revision(r).review(in));
    assertThat(thrown)
        .hasMessageThat()
        .contains("Cannot reduce vote on labels for closed change: Code-Review");
  }

  @TestProjectInput(submitType = SubmitType.CHERRY_PICK)
  @Test
  public void approvalCopiedDuringSubmitIsNotPostSubmit() throws Exception {
    PushOneCommit.Result r = createChange();
    Change.Id id = r.getChange().getId();
    gApi.changes().id(id.get()).current().review(ReviewInput.approve());
    gApi.changes().id(id.get()).current().submit();

    ChangeData cd = r.getChange();
    assertThat(cd.patchSets()).hasSize(2);
    PatchSetApproval psa =
        Iterators.getOnlyElement(
            cd.currentApprovals().stream().filter(a -> !a.isLegacySubmit()).iterator());
    assertThat(psa.patchSetId().get()).isEqualTo(2);
    assertThat(psa.label()).isEqualTo(LabelId.CODE_REVIEW);
    assertThat(psa.value()).isEqualTo(2);
    assertThat(psa.postSubmit()).isFalse();
  }

  @Test
  public void voteOnAbandonedChange() throws Exception {
    PushOneCommit.Result r = createChange();
    gApi.changes().id(r.getChangeId()).abandon();
    ResourceConflictException thrown =
        assertThrows(
            ResourceConflictException.class,
            () -> gApi.changes().id(r.getChangeId()).current().review(ReviewInput.reject()));
    assertThat(thrown).hasMessageThat().contains("change is closed");
  }

  @Test
  public void voteNotAllowedWithoutPermission() throws Exception {
    PushOneCommit.Result r = createChange();
    requestScopeOperations.setApiUser(user.id());
    AuthException thrown =
        assertThrows(AuthException.class, () -> change(r).current().review(ReviewInput.approve()));
    assertThat(thrown).hasMessageThat().contains("is restricted");
  }

  @Test
  public void cherryPick() throws Exception {
    PushOneCommit.Result r = pushTo("refs/for/master%topic=someTopic");
    CherryPickInput in = new CherryPickInput();
    in.destination = "foo";
    in.message = "it goes to stable branch";
    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());

    assertThat(orig.get().messages).hasSize(1);
    ChangeInfo changeInfo = orig.revision(r.getCommit().name()).cherryPickAsInfo(in);
    assertThat(changeInfo.containsGitConflicts).isNull();
    assertThat(changeInfo.workInProgress).isNull();
    ChangeApi cherry = gApi.changes().id(changeInfo._number);

    ChangeInfo cherryPickChangeInfoWithDetails = cherry.get();
    assertThat(cherryPickChangeInfoWithDetails.workInProgress).isNull();
    assertThat(cherryPickChangeInfoWithDetails.messages).hasSize(1);
    Iterator<ChangeMessageInfo> cherryIt = cherryPickChangeInfoWithDetails.messages.iterator();
    assertThat(cherryIt.next().message).isEqualTo("Patch Set 1: Cherry Picked from branch master.");

    assertThat(cherry.get().subject).contains(in.message);
    assertThat(cherry.get().topic).isEqualTo("someTopic-foo");
    assertThat(cherry.get().cherryPickOfChange).isEqualTo(orig.get()._number);
    assertThat(cherry.get().cherryPickOfPatchSet).isEqualTo(1);

    cherry.current().review(ReviewInput.approve());
    cherry.current().submit();
  }

  @Test
  public void cherryPickAfterUpdatingPreferredEmail() throws Exception {
    String emailOne = "email1@example.com";
    Account.Id testUser = accountOperations.newAccount().preferredEmail(emailOne).create();

    // Create target branch to cherry-pick to
    String branch = "foo";
    gApi.projects().name(project.get()).branch(branch).create(new BranchInput());

    // Create change to cherry-pick
    Change.Id changeId =
        changeOperations
            .newChange()
            .project(project)
            .file("file")
            .content("content")
            .owner(testUser)
            .create();

    // Change preferred email for the user
    String emailTwo = "email2@example.com";
    accountOperations.account(testUser).forUpdate().preferredEmail(emailTwo).update();
    requestScopeOperations.setApiUser(testUser);

    // Cherry-pick the change
    String commit = gApi.changes().id(changeId.get()).get().getCurrentRevision().commit.commit;
    CherryPickInput input = new CherryPickInput();
    input.destination = branch;
    input.message = "cherry-pick to foo branch";
    ChangeInfo changeInfo =
        gApi.changes().id(changeId.get()).revision(commit).cherryPick(input).get();
    assertThat(changeInfo.getCurrentRevision().commit.committer.email).isEqualTo(emailOne);
  }

  @Test
  public void cherryPickWithoutMessage() throws Exception {
    String branch = "foo";

    // Create change to cherry-pick
    PushOneCommit.Result change = createChange();
    RevCommit revCommit = change.getCommit();

    // Create target branch to cherry-pick to.
    gApi.projects().name(project.get()).branch(branch).create(new BranchInput());

    // Cherry-pick without message.
    CherryPickInput input = new CherryPickInput();
    input.destination = branch;
    String changeId =
        gApi.changes()
            .id(change.getChangeId())
            .revision(revCommit.name())
            .cherryPickAsInfo(input)
            .id;

    // Expect that the message of the cherry-picked commit was used for the cherry-pick change.
    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
    assertThat(revInfo).isNotNull();
    assertThat(revInfo.commit.message).isEqualTo(revCommit.getFullMessage());
  }

  @Test
  public void cherryPickSetChangeId() throws Exception {
    PushOneCommit.Result r = pushTo("refs/for/master");
    CherryPickInput in = new CherryPickInput();
    in.destination = "foo";
    String id = "Ideadbeefdeadbeefdeadbeefdeadbeefdeadbe3f";
    in.message = "it goes to foo branch\n\nChange-Id: " + id;

    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());

    assertThat(orig.get().messages).hasSize(1);
    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);

    ChangeInfo changeInfo = cherry.get();

    // The cherry-pick honors the ChangeId specified in the input message:
    RevisionInfo revInfo = changeInfo.revisions.get(changeInfo.currentRevision);
    // New change was created.
    assertThat(changeInfo._number).isGreaterThan(orig.get()._number);
    assertThat(changeInfo.changeId).isEqualTo(id);
    assertThat(revInfo).isNotNull();
    assertThat(revInfo.commit.message.trim()).endsWith(id);
  }

  @Test
  public void cherryPickWithNoTopic() throws Exception {
    PushOneCommit.Result r = pushTo("refs/for/master");
    CherryPickInput in = new CherryPickInput();
    in.destination = "foo";
    in.message = "it goes to stable branch";
    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());

    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
    assertThat(cherry.get().topic).isNull();
    cherry.current().review(ReviewInput.approve());
    cherry.current().submit();
  }

  @Test
  public void cherryPickWithSetTopic() throws Exception {
    PushOneCommit.Result r = pushTo("refs/for/master");
    CherryPickInput in = new CherryPickInput();
    in.destination = "foo";
    in.topic = "topic";
    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());

    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
    assertThat(cherry.get().topic).isEqualTo("topic");
  }

  @Test
  public void cherryPickNewPatchsetWithSetTopic() throws Exception {
    PushOneCommit.Result r = pushTo("refs/for/master");
    CherryPickInput in = new CherryPickInput();
    in.destination = "foo";
    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());

    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
    assertThat(cherry.get().topic).isNull();
    in.topic = "topic";
    cherry = orig.revision(r.getCommit().name()).cherryPick(in);
    assertThat(cherry.get().topic).isEqualTo("topic");
  }

  @Test
  public void cherryPickNewPatchsetWithNoTopic() throws Exception {
    PushOneCommit.Result r = pushTo("refs/for/master");
    CherryPickInput in = new CherryPickInput();
    in.destination = "foo";
    in.topic = "topic";
    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());

    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
    assertThat(cherry.get().topic).isEqualTo("topic");

    in.topic = null;
    cherry = orig.revision(r.getCommit().name()).cherryPick(in);
    // confirm that the topic doesn't change when not specified.
    assertThat(cherry.get().topic).isEqualTo("topic");
  }

  @Test
  public void cherryPickWorkInProgressChange() throws Exception {
    PushOneCommit.Result r = pushTo("refs/for/master%wip");
    CherryPickInput in = new CherryPickInput();
    in.destination = "foo";
    in.message = "cherry pick message";
    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());

    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);
    assertThat(cherry.get().cherryPickOfChange).isEqualTo(orig.get()._number);
    assertThat(cherry.get().cherryPickOfPatchSet).isEqualTo(1);
    assertThat(cherry.get().workInProgress).isTrue();
  }

  @Test
  public void cherryPickToSameBranch() throws Exception {
    PushOneCommit.Result r = createChange();
    ChangeApi change = gApi.changes().id(project.get() + "~master~" + r.getChangeId());
    CherryPickInput in = new CherryPickInput();
    in.destination = "master";
    in.message = "it generates a new patch set\n\nChange-Id: " + r.getChangeId();
    ChangeInfo cherryInfo = change.revision(r.getCommit().name()).cherryPick(in).get();
    assertThat(cherryInfo.messages).hasSize(2);
    Iterator<ChangeMessageInfo> cherryIt = cherryInfo.messages.iterator();
    assertThat(cherryInfo.cherryPickOfChange).isEqualTo(change.get()._number);

    // Existing change was updated.
    assertThat(cherryInfo._number).isEqualTo(change.get()._number);
    assertThat(cherryInfo.cherryPickOfPatchSet).isEqualTo(1);
    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");
  }

  @Test
  public void cherryPickToSameBranchWithRebase() throws Exception {
    // Push a new change, then merge it
    PushOneCommit.Result baseChange = createChange();
    String triplet = project.get() + "~master~" + baseChange.getChangeId();
    RevisionApi baseRevision = gApi.changes().id(triplet).current();
    baseRevision.review(ReviewInput.approve());
    baseRevision.submit();

    // Push a new change (change 1)
    PushOneCommit.Result r1 = createChange();

    // Push another new change (change 2)
    String subject = "Test change";
    PushOneCommit push =
        pushFactory.create(
            admin.newIdent(), testRepo, subject, "another_file.txt", "another content");
    PushOneCommit.Result r2 = push.to("refs/for/master");

    // Change 2's parent should be change 1
    assertThat(r2.getCommit().getParents()[0].name()).isEqualTo(r1.getCommit().name());

    // Cherry pick change 2 onto the same branch
    triplet = project.get() + "~master~" + r2.getChangeId();
    ChangeApi orig = gApi.changes().id(triplet);
    CherryPickInput in = new CherryPickInput();
    in.destination = "master";
    in.message = subject + "\n\nChange-Id: " + r2.getChangeId();
    ChangeApi cherry = orig.revision(r2.getCommit().name()).cherryPick(in);
    ChangeInfo cherryInfo = cherry.get();
    assertThat(cherryInfo.messages).hasSize(2);
    Iterator<ChangeMessageInfo> cherryIt = cherryInfo.messages.iterator();
    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 1.");
    assertThat(cherryIt.next().message).isEqualTo("Uploaded patch set 2.");

    // Parent of change 2 should now be the change that was merged, i.e.
    // change 2 is rebased onto the head of the master branch.
    String newParent =
        cherryInfo.revisions.get(cherryInfo.currentRevision).commit.parents.get(0).commit;
    assertThat(newParent).isEqualTo(baseChange.getCommit().name());
  }

  @Test
  public void cherryPickIdenticalTree() throws Exception {
    PushOneCommit.Result r = createChange();
    CherryPickInput in = new CherryPickInput();
    in.destination = "foo";
    in.message = "it goes to stable branch";
    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());
    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r.getChangeId());

    assertThat(orig.get().messages).hasSize(1);
    ChangeApi cherry = orig.revision(r.getCommit().name()).cherryPick(in);

    assertThat(cherry.get().subject).contains(in.message);
    cherry.current().review(ReviewInput.approve());
    cherry.current().submit();

    ResourceConflictException thrown =
        assertThrows(
            ResourceConflictException.class,
            () -> orig.revision(r.getCommit().name()).cherryPick(in));
    assertThat(thrown).hasMessageThat().contains("Cherry pick failed: identical tree");

    in.allowEmpty = true;
    ChangeInfo cherryPickChange = orig.revision(r.getCommit().name()).cherryPick(in).get();
    assertThat(cherryPickChange.cherryPickOfChange).isEqualTo(r.getChange().change().getChangeId());

    // An empty commit is created
    assertThat(cherryPickChange.insertions).isEqualTo(0);
    assertThat(cherryPickChange.deletions).isEqualTo(0);
  }

  @Test
  public void cherryPickConflict() throws Exception {
    PushOneCommit.Result r = createChange();
    CherryPickInput in = new CherryPickInput();
    in.destination = "foo";
    in.message = "it goes to stable branch";
    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());

    PushOneCommit push =
        pushFactory.create(
            admin.newIdent(),
            testRepo,
            PushOneCommit.SUBJECT,
            PushOneCommit.FILE_NAME,
            "another content");
    push.to("refs/heads/foo");

    String triplet = project.get() + "~master~" + r.getChangeId();
    ChangeApi orig = gApi.changes().id(triplet);
    assertThat(orig.get().messages).hasSize(1);

    ResourceConflictException thrown =
        assertThrows(
            ResourceConflictException.class,
            () -> orig.revision(r.getCommit().name()).cherryPick(in));
    assertThat(thrown).hasMessageThat().contains("Cherry pick failed: merge conflict");
  }

  @Test
  public void cherryPickConflictWithAllowConflicts() throws Exception {
    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();

    // Create a branch and push a commit to it (by-passing review)
    String destBranch = "foo";
    gApi.projects().name(project.get()).branch(destBranch).create(new BranchInput());
    String destContent = "some content";
    PushOneCommit push =
        pushFactory.create(
            admin.newIdent(),
            testRepo,
            PushOneCommit.SUBJECT,
            ImmutableMap.of(PushOneCommit.FILE_NAME, destContent, "foo.txt", "foo"));
    push.to("refs/heads/" + destBranch);

    // Create a change on master with a commit that conflicts with the commit on the other branch.
    testRepo.reset(initial);
    String changeContent = "another content";
    push =
        pushFactory.create(
            admin.newIdent(),
            testRepo,
            PushOneCommit.SUBJECT,
            ImmutableMap.of(PushOneCommit.FILE_NAME, changeContent, "bar.txt", "bar"));
    PushOneCommit.Result r = push.to("refs/for/master%topic=someTopic");

    // Verify before the cherry-pick that the change has exactly 1 message.
    ChangeApi changeApi = change(r);
    assertThat(changeApi.get().messages).hasSize(1);

    // Cherry-pick the change to the other branch, that should fail with a conflict.
    CherryPickInput in = new CherryPickInput();
    in.destination = destBranch;
    in.message = "Cherry-Pick";
    ResourceConflictException thrown =
        assertThrows(
            ResourceConflictException.class,
            () -> changeApi.revision(r.getCommit().name()).cherryPickAsInfo(in));
    assertThat(thrown).hasMessageThat().startsWith("Cherry pick failed: merge conflict");

    // Cherry-pick with auto merge should succeed.
    in.allowConflicts = true;
    ChangeInfo cherryPickChange = changeApi.revision(r.getCommit().name()).cherryPickAsInfo(in);
    assertThat(cherryPickChange.containsGitConflicts).isTrue();
    assertThat(cherryPickChange.workInProgress).isTrue();

    // Verify that subject and topic on the cherry-pick change have been correctly populated.
    assertThat(cherryPickChange.subject).contains(in.message);
    assertThat(cherryPickChange.topic).isEqualTo("someTopic-" + destBranch);

    // Verify that the file content in the cherry-pick change is correct.
    // We expect that it has conflict markers to indicate the conflict.
    BinaryResult bin =
        gApi.changes()
            .id(cherryPickChange._number)
            .current()
            .file(PushOneCommit.FILE_NAME)
            .content();
    ByteArrayOutputStream os = new ByteArrayOutputStream();
    bin.writeTo(os);
    String fileContent = new String(os.toByteArray(), UTF_8);
    String destSha1 = abbreviateName(projectOperations.project(project).getHead(destBranch), 6);
    String changeSha1 = abbreviateName(r.getCommit(), 6);
    assertThat(fileContent)
        .isEqualTo(
            "<<<<<<< HEAD   ("
                + destSha1
                + " test commit)\n"
                + destContent
                + "\n"
                + "=======\n"
                + changeContent
                + "\n"
                + ">>>>>>> CHANGE ("
                + changeSha1
                + " test commit)\n");

    // Get details of cherry-pick change.
    ChangeInfo cherryPickChangeWithDetails = gApi.changes().id(cherryPickChange._number).get();
    assertThat(cherryPickChangeWithDetails.workInProgress).isTrue();

    // Verify that a message has been posted on the cherry-pick change.
    assertThat(cherryPickChangeWithDetails.messages).hasSize(1);
    Iterator<ChangeMessageInfo> cherryIt = cherryPickChangeWithDetails.messages.iterator();
    assertThat(cherryIt.next().message)
        .isEqualTo(
            "Patch Set 1: Cherry Picked from branch master.\n\n"
                + "The following files contain Git conflicts:\n"
                + "* "
                + PushOneCommit.FILE_NAME);
  }

  @Test
  public void cherryPickWithValidationOptions() throws Exception {
    PushOneCommit.Result r = createChange();

    CherryPickInput in = new CherryPickInput();
    in.destination = "foo";
    in.message = "Cherry Pick";
    in.validationOptions = ImmutableMap.of("key", "value");

    gApi.projects().name(project.get()).branch(in.destination).create(new BranchInput());

    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
    try (Registration registration =
        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
      gApi.changes().id(r.getChangeId()).current().cherryPickAsInfo(in);
      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
          .containsExactly("key", "value");
    }
  }

  @Test
  public void cherryPickToExistingChangeUpdatesCherryPickOf() throws Exception {
    PushOneCommit.Result r1 =
        pushFactory
            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "a")
            .to("refs/for/master");
    String t1 = project.get() + "~master~" + r1.getChangeId();
    ChangeApi orig = gApi.changes().id(project.get() + "~master~" + r1.getChangeId());

    BranchInput bin = new BranchInput();
    bin.revision = r1.getCommit().getParent(0).name();
    gApi.projects().name(project.get()).branch("foo").create(bin);

    PushOneCommit.Result r2 =
        pushFactory
            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "b", r1.getChangeId())
            .to("refs/for/foo");
    String t2 = project.get() + "~foo~" + r2.getChangeId();

    CherryPickInput in = new CherryPickInput();
    in.destination = "foo";
    in.message = r1.getCommit().getFullMessage();
    ChangeApi cherry = gApi.changes().id(t1).current().cherryPick(in);
    assertThat(get(t2, ALL_REVISIONS).revisions).hasSize(2);
    assertThat(cherry.get().cherryPickOfChange).isEqualTo(orig.get()._number);
    assertThat(cherry.get().cherryPickOfPatchSet).isEqualTo(1);

    PushOneCommit.Result r3 = amendChange(r1.getChangeId(), SUBJECT, "b.txt", "b");
    in = new CherryPickInput();
    in.destination = "foo";
    in.message = r3.getCommit().getFullMessage();
    cherry = gApi.changes().id(t1).current().cherryPick(in);
    assertThat(cherry.get()._number).isEqualTo(info(t2)._number);
    assertThat(cherry.get().cherryPickOfChange).isEqualTo(orig.get()._number);
    assertThat(cherry.get().cherryPickOfPatchSet).isEqualTo(2);
  }

  @Test
  public void cherryPickToAbandonedChange() throws Exception {
    PushOneCommit.Result r1 =
        pushFactory
            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "a")
            .to("refs/for/master");
    String t1 = project.get() + "~master~" + r1.getChangeId();

    BranchInput bin = new BranchInput();
    bin.revision = r1.getCommit().getParent(0).name();
    gApi.projects().name(project.get()).branch("foo").create(bin);

    PushOneCommit.Result r2 =
        pushFactory
            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "b", r1.getChangeId())
            .to("refs/for/foo");
    String t2 = project.get() + "~foo~" + r2.getChangeId();
    gApi.changes().id(t2).abandon();

    CherryPickInput in = new CherryPickInput();
    in.destination = "foo";
    in.message = r1.getCommit().getFullMessage();
    BadRequestException thrown =
        assertThrows(
            BadRequestException.class, () -> gApi.changes().id(t1).current().cherryPick(in));
    assertThat(thrown)
        .hasMessageThat()
        .isEqualTo(
            String.format(
                "Cherry-pick with Change-Id %s could not update the existing change %d in "
                    + "destination branch refs/heads/foo of project %s, because "
                    + "the change was closed (ABANDONED)",
                r1.getChangeId(), info(t2)._number, project.get()));

    gApi.changes().id(t2).restore();
    gApi.changes().id(t1).current().cherryPick(in);
    assertThat(get(t2, ALL_REVISIONS).revisions).hasSize(2);
    assertThat(gApi.changes().id(t2).current().file(FILE_NAME).content().asString()).isEqualTo("a");
  }

  @Test
  public void cherryPickToExistingMergedChange() throws Exception {
    PushOneCommit.Result r1 =
        pushFactory
            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "a")
            .to("refs/for/master");

    BranchInput bin = new BranchInput();
    bin.revision = r1.getCommit().getParent(0).name();
    gApi.projects().name(project.get()).branch("foo").create(bin);

    PushOneCommit.Result r2 =
        pushFactory
            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "b", r1.getChangeId())
            .to("refs/for/foo");
    String t2 = project.get() + "~foo~" + r2.getChangeId();

    gApi.changes().id(t2).current().review(ReviewInput.approve());
    gApi.changes().id(t2).current().submit();

    CherryPickInput in = new CherryPickInput();
    in.destination = "foo";
    in.message = r1.getCommit().getFullMessage();
    in.allowConflicts = true;
    in.allowEmpty = true;
    BadRequestException thrown =
        assertThrows(
            BadRequestException.class, () -> gApi.changes().id(t2).current().cherryPick(in));
    assertThat(thrown)
        .hasMessageThat()
        .isEqualTo(
            String.format(
                "Cherry-pick with Change-Id %s could not update the existing change %d in "
                    + "destination branch refs/heads/foo of project %s, because "
                    + "the change was closed (MERGED)",
                r1.getChangeId(), info(t2)._number, project.get()));
  }

  @Test
  public void cherryPickMergeRelativeToDefaultParent() throws Exception {
    String parent1FileName = "a.txt";
    String parent2FileName = "b.txt";
    PushOneCommit.Result mergeChangeResult =
        createCherryPickableMerge(parent1FileName, parent2FileName);

    String cherryPickBranchName = "branch_for_cherry_pick";
    createBranch(BranchNameKey.create(project, cherryPickBranchName));

    CherryPickInput cherryPickInput = new CherryPickInput();
    cherryPickInput.destination = cherryPickBranchName;
    cherryPickInput.message = "Cherry-pick a merge commit to another branch";

    ChangeInfo cherryPickedChangeInfo =
        gApi.changes()
            .id(mergeChangeResult.getChangeId())
            .current()
            .cherryPick(cherryPickInput)
            .get();

    Map<String, FileInfo> cherryPickedFilesByName =
        cherryPickedChangeInfo.revisions.get(cherryPickedChangeInfo.currentRevision).files;
    assertThat(cherryPickedFilesByName).containsKey(parent2FileName);
    assertThat(cherryPickedFilesByName).doesNotContainKey(parent1FileName);
  }

  @Test
  public void cherryPickMergeRelativeToSpecificParent() throws Exception {
    String parent1FileName = "a.txt";
    String parent2FileName = "b.txt";
    PushOneCommit.Result mergeChangeResult =
        createCherryPickableMerge(parent1FileName, parent2FileName);

    String cherryPickBranchName = "branch_for_cherry_pick";
    createBranch(BranchNameKey.create(project, cherryPickBranchName));

    CherryPickInput cherryPickInput = new CherryPickInput();
    cherryPickInput.destination = cherryPickBranchName;
    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
    cherryPickInput.parent = 2;

    ChangeInfo cherryPickedChangeInfo =
        gApi.changes()
            .id(mergeChangeResult.getChangeId())
            .current()
            .cherryPick(cherryPickInput)
            .get();

    Map<String, FileInfo> cherryPickedFilesByName =
        cherryPickedChangeInfo.revisions.get(cherryPickedChangeInfo.currentRevision).files;
    assertThat(cherryPickedFilesByName).containsKey(parent1FileName);
    assertThat(cherryPickedFilesByName).doesNotContainKey(parent2FileName);
  }

  @Test
  public void cherryPickMergeUsingInvalidParent() throws Exception {
    String parent1FileName = "a.txt";
    String parent2FileName = "b.txt";
    PushOneCommit.Result mergeChangeResult =
        createCherryPickableMerge(parent1FileName, parent2FileName);

    String cherryPickBranchName = "branch_for_cherry_pick";
    createBranch(BranchNameKey.create(project, cherryPickBranchName));

    CherryPickInput cherryPickInput = new CherryPickInput();
    cherryPickInput.destination = cherryPickBranchName;
    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
    cherryPickInput.parent = 0;

    BadRequestException thrown =
        assertThrows(
            BadRequestException.class,
            () ->
                gApi.changes()
                    .id(mergeChangeResult.getChangeId())
                    .current()
                    .cherryPick(cherryPickInput));
    assertThat(thrown)
        .hasMessageThat()
        .contains("Cherry Pick: Parent 0 does not exist. Please specify a parent in range [1, 2].");
  }

  @Test
  public void cherryPickMergeUsingNonExistentParent() throws Exception {
    String parent1FileName = "a.txt";
    String parent2FileName = "b.txt";
    PushOneCommit.Result mergeChangeResult =
        createCherryPickableMerge(parent1FileName, parent2FileName);

    String cherryPickBranchName = "branch_for_cherry_pick";
    createBranch(BranchNameKey.create(project, cherryPickBranchName));

    CherryPickInput cherryPickInput = new CherryPickInput();
    cherryPickInput.destination = cherryPickBranchName;
    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
    cherryPickInput.parent = 3;

    BadRequestException thrown =
        assertThrows(
            BadRequestException.class,
            () ->
                gApi.changes()
                    .id(mergeChangeResult.getChangeId())
                    .current()
                    .cherryPick(cherryPickInput));
    assertThat(thrown)
        .hasMessageThat()
        .contains("Cherry Pick: Parent 3 does not exist. Please specify a parent in range [1, 2].");
  }

  @Test
  public void cherryPickNotify() throws Exception {
    createBranch(BranchNameKey.create(project, "branch-1"));
    createBranch(BranchNameKey.create(project, "branch-2"));
    createBranch(BranchNameKey.create(project, "branch-3"));

    // Creates a change for 'admin'.
    PushOneCommit.Result result = createChange();
    String changeId = project.get() + "~master~" + result.getChangeId();

    // 'user' cherry-picks the change to a new branch, the source change's author/committer('admin')
    // will be added as cc of the newly created change.
    requestScopeOperations.setApiUser(user.id());
    CherryPickInput input = new CherryPickInput();
    input.message = "it goes to a new branch";

    // Enable the notification. 'admin' as a reviewer should be notified.
    input.destination = "branch-1";
    input.notify = NotifyHandling.ALL;
    sender.clear();
    gApi.changes().id(changeId).current().cherryPick(input);
    assertNotifyCc(admin);

    // Disable the notification. 'admin' as a reviewer should not be notified any more.
    input.destination = "branch-2";
    input.notify = NotifyHandling.NONE;
    sender.clear();
    gApi.changes().id(changeId).current().cherryPick(input);
    assertThat(sender.getMessages()).isEmpty();

    // Disable the notification. The user provided in the 'notifyDetails' should still be notified.
    TestAccount userToNotify = accountCreator.user2();
    input.destination = "branch-3";
    input.notify = NotifyHandling.NONE;
    input.notifyDetails =
        ImmutableMap.of(RecipientType.TO, new NotifyInfo(ImmutableList.of(userToNotify.email())));
    sender.clear();
    gApi.changes().id(changeId).current().cherryPick(input);
    assertNotifyTo(userToNotify);
  }

  @Test
  public void cherryPickKeepReviewers() throws Exception {
    createBranch(BranchNameKey.create(project, "stable"));

    // Change is created by 'admin'.
    PushOneCommit.Result r = createChange();
    // Change is approved by 'admin2'. Change is CC'd to 'user'.
    requestScopeOperations.setApiUser(accountCreator.admin2().id());
    ReviewInput in = ReviewInput.approve();
    in.reviewer(user.email(), ReviewerState.CC, true);
    gApi.changes().id(r.getChangeId()).current().review(in);

    // Change is cherrypicked by 'user2'.
    requestScopeOperations.setApiUser(accountCreator.user2().id());
    CherryPickInput cin = new CherryPickInput();
    cin.message = "this need to go to stable";
    cin.destination = "stable";
    cin.keepReviewers = true;
    Map<ReviewerState, Collection<AccountInfo>> result =
        gApi.changes().id(r.getChangeId()).current().cherryPick(cin).get().reviewers;

    // 'admin' should be a reviewer as the old owner.
    // 'admin2' should be a reviewer as the old reviewer.
    // 'user' should be on CC.
    assertThat(result).containsKey(ReviewerState.REVIEWER);
    List<Integer> reviewers =
        result.get(ReviewerState.REVIEWER).stream().map(a -> a._accountId).collect(toList());
    assertThat(result).containsKey(ReviewerState.CC);
    List<Integer> ccs =
        result.get(ReviewerState.CC).stream().map(a -> a._accountId).collect(toList());
    assertThat(ccs).containsExactly(user.id().get());
    assertThat(reviewers).containsExactly(admin.id().get(), accountCreator.admin2().id().get());
  }

  @Test
  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
  public void cherryPickWithNonVisibleUsers() throws Exception {
    // Create a target branch for the cherry-pick.
    createBranch(BranchNameKey.create(project, "stable"));

    // Define readable names for the users we use in this test.
    TestAccount cherryPicker = user;
    TestAccount changeOwner = admin;
    TestAccount reviewer = accountCreator.user2();
    TestAccount cc =
        accountCreator.create("user3", "user3@example.com", "User3", /* displayName= */ null);
    TestAccount authorCommitter =
        accountCreator.create("user4", "user4@example.com", "User4", /* displayName= */ null);

    // Check that the cherry-picker can neither see the changeOwner, the reviewer, the cc nor the
    // authorCommitter.
    requestScopeOperations.setApiUser(cherryPicker.id());
    assertThatAccountIsNotVisible(changeOwner, reviewer, cc, authorCommitter);

    // Create the change with authorCommitter as the author and the committer.
    requestScopeOperations.setApiUser(changeOwner.id());
    PushOneCommit push = pushFactory.create(authorCommitter.newIdent(), testRepo);
    PushOneCommit.Result r = push.to("refs/for/master");
    r.assertOkStatus();

    // Check that authorCommitter was set as the author and committer.
    ChangeInfo changeInfo = gApi.changes().id(r.getChangeId()).get();
    CommitInfo commit = changeInfo.revisions.get(changeInfo.currentRevision).commit;
    assertThat(commit.author.email).isEqualTo(authorCommitter.email());
    assertThat(commit.committer.email).isEqualTo(authorCommitter.email());

    // Pushing a commit with a forged author/committer adds the author/committer as a CC.
    assertCcs(r.getChangeId(), authorCommitter);

    // Remove the author/committer as a CC because because otherwise there are two signals for CCing
    // authorCommitter on the cherry-pick change: once because they are author and committer and
    // once because they are a CC. For authorCommitter we only want to test the first signal here
    // (the second signal is covered by adding an explicit CC below).
    gApi.changes().id(r.getChangeId()).reviewer(authorCommitter.email()).remove();
    assertNoCcs(r.getChangeId());

    // Add reviewer and cc.
    ReviewInput reviewerInput = ReviewInput.approve();
    reviewerInput.reviewer(reviewer.email());
    reviewerInput.cc(cc.email());
    gApi.changes().id(r.getChangeId()).current().review(reviewerInput);

    // Approve and submit the change.
    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
    gApi.changes().id(r.getChangeId()).current().submit();

    // Cherry-pick the change.
    requestScopeOperations.setApiUser(cherryPicker.id());
    CherryPickInput cherryPickInput = new CherryPickInput();
    cherryPickInput.message = "Cherry-pick to stable branch";
    cherryPickInput.destination = "stable";
    cherryPickInput.keepReviewers = true;
    String cherryPickChangeId =
        gApi.changes().id(r.getChangeId()).current().cherryPick(cherryPickInput).get().id;

    // Cherry-pick doesn't check the visibility of explicit reviewers/CCs. Since the cherry-picker
    // can see the cherry-picked change, they can also see its reviewers/CCs. This means preserving
    // them on the cherry-pick change doesn't expose their account existence and it's OK to keep
    // them even if their accounts are not visible to the cherry-picker.
    // In contrast to this for implicit CCs that are added for the author/committer the account
    // visibility is checked, but if their accounts are not visible the CC is silently dropped (so
    // that the cherry-pick request can still succeed). Since in this case authorCommitter is not
    // visible, we expect that CCing them is being dropped and hence authorCommitter is not returned
    // as a CC here. The reason that the visibility for author/committer must be checked is that
    // author/committer may not match a Gerrit account (if they are forged). This means by seeing
    // the author/committer on the cherry-picked change, it's not possible to deduce that these
    // Gerrit accounts exists, but if they would be added as a CC on the cherry-pick change even if
    // they are not visible the account existence would be exposed.
    assertReviewers(cherryPickChangeId, changeOwner, reviewer);
    assertCcs(cherryPickChangeId, cc);
  }

  @Test
  public void cherryPickToMergedChangeRevision() throws Exception {
    createBranch(BranchNameKey.create(project, "foo"));

    PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
    dstChange.assertOkStatus();

    merge(dstChange);

    PushOneCommit.Result result = createChange(testRepo, "foo", SUBJECT, "b.txt", "c", "t");
    result.assertOkStatus();
    merge(result);

    PushOneCommit.Result srcChange = createChange();

    CherryPickInput input = new CherryPickInput();
    input.destination = "foo";
    input.base = dstChange.getCommit().name();
    input.message = srcChange.getCommit().getFullMessage();
    ChangeInfo changeInfo =
        gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
    assertCherryPickResult(changeInfo, input, srcChange.getChangeId());
  }

  @Test
  public void cherryPickToOpenChangeRevision() throws Exception {
    createBranch(BranchNameKey.create(project, "foo"));

    PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
    dstChange.assertOkStatus();

    PushOneCommit.Result srcChange = createChange();

    CherryPickInput input = new CherryPickInput();
    input.destination = "foo";
    input.base = dstChange.getCommit().name();
    input.message = srcChange.getCommit().getFullMessage();
    ChangeInfo changeInfo =
        gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
    assertCherryPickResult(changeInfo, input, srcChange.getChangeId());
  }

  @Test
  public void cherryPickToNonVisibleChangeFails() throws Exception {
    createBranch(BranchNameKey.create(project, "foo"));

    PushOneCommit.Result dstChange = createChange(testRepo, "foo", SUBJECT, "b.txt", "b", "t");
    dstChange.assertOkStatus();

    gApi.changes().id(dstChange.getChangeId()).setPrivate(true, null);

    PushOneCommit.Result srcChange = createChange();

    CherryPickInput input = new CherryPickInput();
    input.destination = "foo";
    input.base = dstChange.getCommit().name();
    input.message = srcChange.getCommit().getFullMessage();

    requestScopeOperations.setApiUser(user.id());
    UnprocessableEntityException thrown =
        assertThrows(
            UnprocessableEntityException.class,
            () -> gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get());
    assertThat(thrown)
        .hasMessageThat()
        .contains(String.format("Commit %s does not exist on branch refs/heads/foo", input.base));
  }

  @Test
  public void cherryPickToAbandonedChangeFails() throws Exception {
    PushOneCommit.Result change1 = createChange();
    PushOneCommit.Result change2 = createChange();
    gApi.changes().id(change2.getChangeId()).abandon();

    CherryPickInput input = new CherryPickInput();
    input.destination = "master";
    input.base = change2.getCommit().name();
    input.message = change1.getCommit().getFullMessage();

    ResourceConflictException thrown =
        assertThrows(
            ResourceConflictException.class,
            () -> gApi.changes().id(change1.getChangeId()).current().cherryPick(input));
    assertThat(thrown)
        .hasMessageThat()
        .contains(
            String.format(
                "Change %s with commit %s is abandoned",
                change2.getChange().getId().get(), input.base));
  }

  @Test
  public void cherryPickWithInvalidBaseFails() throws Exception {
    PushOneCommit.Result change1 = createChange();

    CherryPickInput input = new CherryPickInput();
    input.destination = "master";
    input.base = "invalid-sha1";
    input.message = change1.getCommit().getFullMessage();

    BadRequestException thrown =
        assertThrows(
            BadRequestException.class,
            () -> gApi.changes().id(change1.getChangeId()).current().cherryPick(input));
    assertThat(thrown)
        .hasMessageThat()
        .contains(String.format("Base %s doesn't represent a valid SHA-1", input.base));
  }

  @Test
  public void cherryPickToCommitWithoutChangeId() throws Exception {
    RevCommit commit1 = createNewCommitWithoutChangeId("refs/heads/foo", "a.txt", "content 1");

    createNewCommitWithoutChangeId("refs/heads/foo", "a.txt", "content 2");

    PushOneCommit.Result srcChange = createChange("subject", "b.txt", "b");
    srcChange.assertOkStatus();

    CherryPickInput input = new CherryPickInput();
    input.destination = "foo";
    input.base = commit1.name();
    input.message = srcChange.getCommit().getFullMessage();
    ChangeInfo changeInfo =
        gApi.changes().id(srcChange.getChangeId()).current().cherryPick(input).get();
    assertCherryPickResult(changeInfo, input, srcChange.getChangeId());
  }

  @Test
  public void cherryPickToNonExistingBranch() throws Exception {
    PushOneCommit.Result result = createChange();

    CherryPickInput input = new CherryPickInput();
    input.message = "foo bar";
    input.destination = "non-existing";
    // TODO(ekempin): This should rather result in an UnprocessableEntityException.
    BadRequestException thrown =
        assertThrows(
            BadRequestException.class,
            () -> gApi.changes().id(result.getChangeId()).current().cherryPick(input));
    assertThat(thrown)
        .hasMessageThat()
        .isEqualTo(
            String.format("Branch %s does not exist.", RefNames.REFS_HEADS + input.destination));
  }

  @Test
  public void cherryPickToNonExistingBaseCommit() throws Exception {
    createBranch(BranchNameKey.create(project, "foo"));
    PushOneCommit.Result result = createChange();

    CherryPickInput input = new CherryPickInput();
    input.message = "foo bar";
    input.destination = "foo";
    input.base = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
    UnprocessableEntityException thrown =
        assertThrows(
            UnprocessableEntityException.class,
            () -> gApi.changes().id(result.getChangeId()).current().cherryPick(input));
    assertThat(thrown)
        .hasMessageThat()
        .isEqualTo(String.format("Base %s doesn't exist", input.base));
  }

  @Test
  public void getRelatedCherryPicks() throws Exception {
    PushOneCommit.Result r1 = createChange(SUBJECT, "a.txt", "a");
    PushOneCommit.Result r2 = createChange(SUBJECT, "b.txt", "b");

    String branch = "foo";
    // Create target branch to cherry-pick to.
    gApi.projects().name(project.get()).branch(branch).create(new BranchInput());

    CherryPickInput input = new CherryPickInput();
    input.message = "message";
    input.destination = branch;
    ChangeInfo firstCherryPickResult =
        gApi.changes().id(r1.getChangeId()).current().cherryPickAsInfo(input);

    input.base = gApi.changes().id(firstCherryPickResult.changeId).current().commit(false).commit;
    ChangeInfo secondCherryPickResult =
        gApi.changes().id(r2.getChangeId()).current().cherryPickAsInfo(input);
    assertThat(gApi.changes().id(firstCherryPickResult.changeId).current().related().changes)
        .hasSize(2);
    assertThat(gApi.changes().id(secondCherryPickResult.changeId).current().related().changes)
        .hasSize(2);
  }

  @Test
  public void cherryPickOnMergedChangeIsNotRelated() throws Exception {
    PushOneCommit.Result r1 = createChange(SUBJECT, "a.txt", "a");
    PushOneCommit.Result r2 = createChange(SUBJECT, "b.txt", "b");

    String branch = "foo";
    // Create target branch to cherry-pick to.
    gApi.projects().name(project.get()).branch(branch).create(new BranchInput());

    CherryPickInput input = new CherryPickInput();
    input.message = "message";
    input.destination = branch;
    ChangeInfo firstCherryPickResult =
        gApi.changes().id(r1.getChangeId()).current().cherryPickAsInfo(input);

    gApi.changes().id(firstCherryPickResult.id).current().review(ReviewInput.approve());
    gApi.changes().id(firstCherryPickResult.id).current().submit();

    input.base = gApi.changes().id(firstCherryPickResult.changeId).current().commit(false).commit;
    ChangeInfo secondCherryPickResult =
        gApi.changes().id(r2.getChangeId()).current().cherryPickAsInfo(input);
    assertThat(gApi.changes().id(firstCherryPickResult.changeId).current().related().changes)
        .hasSize(0);
    assertThat(gApi.changes().id(secondCherryPickResult.changeId).current().related().changes)
        .hasSize(0);
  }

  @Test
  @UseClockStep
  public void cherryPickSetsReadyChangeOnNewPatchset() throws Exception {
    PushOneCommit.Result result = pushTo("refs/for/master");
    CherryPickInput input = new CherryPickInput();
    input.destination = "foo";
    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
    ChangeApi originalChange = gApi.changes().id(project.get() + "~master~" + result.getChangeId());

    ChangeApi cherryPick = originalChange.revision(result.getCommit().name()).cherryPick(input);
    String firstCherryPickChangeId = cherryPick.id();
    cherryPick.setWorkInProgress();
    gApi.changes()
        .id(project.get() + "~master~" + result.getChangeId())
        .revision(result.getCommit().name())
        .cherryPick(input);

    ChangeInfo secondCherryPickResult =
        gApi.changes().id(firstCherryPickChangeId).get(ALL_REVISIONS);
    assertThat(secondCherryPickResult.revisions).hasSize(2);
    assertThat(secondCherryPickResult.workInProgress).isNull();
  }

  @Test
  public void canRebase() throws Exception {
    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
    PushOneCommit.Result r1 = push.to("refs/for/master");
    merge(r1);

    push = pushFactory.create(admin.newIdent(), testRepo);
    PushOneCommit.Result r2 = push.to("refs/for/master");
    boolean canRebase =
        gApi.changes().id(r2.getChangeId()).revision(r2.getCommit().name()).canRebase();
    assertThat(canRebase).isFalse();
    merge(r2);

    testRepo.reset(r1.getCommit());
    push = pushFactory.create(admin.newIdent(), testRepo);
    PushOneCommit.Result r3 = push.to("refs/for/master");

    canRebase = gApi.changes().id(r3.getChangeId()).revision(r3.getCommit().name()).canRebase();
    assertThat(canRebase).isTrue();
  }

  @Test
  public void setUnsetReviewedFlag() throws Exception {
    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
    PushOneCommit.Result r = push.to("refs/for/master");

    gApi.changes().id(r.getChangeId()).current().setReviewed(PushOneCommit.FILE_NAME, true);

    assertThat(Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).current().reviewed()))
        .isEqualTo(PushOneCommit.FILE_NAME);

    gApi.changes().id(r.getChangeId()).current().setReviewed(PushOneCommit.FILE_NAME, false);

    assertThat(gApi.changes().id(r.getChangeId()).current().reviewed()).isEmpty();
  }

  @Test
  public void setReviewedFlagWithMultiplePatchSets() throws Exception {
    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
    PushOneCommit.Result r1 = push.to("refs/for/master");

    gApi.changes().id(r1.getChangeId()).current().setReviewed(PushOneCommit.FILE_NAME, true);

    /** Amending the change will result in the file being un-reviewed in the latest patchset */
    PushOneCommit.Result r2 = amendChange(r1.getChangeId());

    assertThat(gApi.changes().id(r2.getChangeId()).current().reviewed()).isEmpty();

    gApi.changes().id(r2.getChangeId()).current().setReviewed(PushOneCommit.FILE_NAME, true);

    assertThat(Iterables.getOnlyElement(gApi.changes().id(r2.getChangeId()).current().reviewed()))
        .isEqualTo(PushOneCommit.FILE_NAME);
  }

  @Test
  public void setUnsetReviewedFlagByFileApi() throws Exception {
    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
    PushOneCommit.Result r = push.to("refs/for/master");

    gApi.changes().id(r.getChangeId()).current().file(PushOneCommit.FILE_NAME).setReviewed(true);

    assertThat(Iterables.getOnlyElement(gApi.changes().id(r.getChangeId()).current().reviewed()))
        .isEqualTo(PushOneCommit.FILE_NAME);

    gApi.changes().id(r.getChangeId()).current().file(PushOneCommit.FILE_NAME).setReviewed(false);

    assertThat(gApi.changes().id(r.getChangeId()).current().reviewed()).isEmpty();
  }

  @Test
  @GerritConfig(
      name = "change.mergeabilityComputationBehavior",
      value = "API_REF_UPDATED_AND_CHANGE_REINDEX")
  public void mergeable() throws Exception {
    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();

    PushOneCommit push1 =
        pushFactory.create(
            admin.newIdent(),
            testRepo,
            PushOneCommit.SUBJECT,
            PushOneCommit.FILE_NAME,
            "push 1 content");

    PushOneCommit.Result r1 = push1.to("refs/for/master");
    assertMergeable(r1.getChangeId(), true);
    merge(r1);

    // Reset client HEAD to initial so the new change is a merge conflict.
    testRepo.reset(initial);

    PushOneCommit push2 =
        pushFactory.create(
            admin.newIdent(),
            testRepo,
            PushOneCommit.SUBJECT,
            PushOneCommit.FILE_NAME,
            "new contents");
    PushOneCommit.Result r2 = push2.to("refs/for/master");
    Change.Id id2 = r2.getChange().getId();
    assertMergeable(r2.getChangeId(), false);

    // Search shows change is not mergeable.
    Callable<List<ChangeInfo>> search =
        () -> gApi.changes().query("is:mergeable change:" + r2.getChangeId()).get();
    assertThat(search.call()).isEmpty();

    // Make the same change in a separate commit and update server HEAD behind Gerrit's back, which
    // will not reindex any open changes.
    try (Repository repo = repoManager.openRepository(project);
        TestRepository<Repository> tr = new TestRepository<>(repo)) {
      String ref = "refs/heads/master";
      assertThat(repo.exactRef(ref).getObjectId()).isEqualTo(r1.getCommit());
      tr.update(ref, tr.getRevWalk().parseCommit(initial));
      tr.branch(ref)
          .commit()
          .message("Side update")
          .add(PushOneCommit.FILE_NAME, "new contents")
          .create();
    }

    // Search shows change is still not mergeable.
    assertThat(search.call()).isEmpty();

    // Using the API returns the correct value, and reindexes as well.
    CountDownLatch reindexed = new CountDownLatch(1);
    ChangeIndexedListener listener =
        new ChangeIndexedListener() {
          @Override
          public void onChangeIndexed(String projectName, int id) {
            if (id == id2.get()) {
              reindexed.countDown();
            }
          }

          @Override
          public void onChangeDeleted(int id) {}
        };

    try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
      assertMergeable(r2.getChangeId(), true);
      reindexed.await();
    }

    List<ChangeInfo> changes = search.call();
    assertThat(changes).hasSize(1);
    assertThat(changes.get(0).changeId).isEqualTo(r2.getChangeId());
    assertThat(changes.get(0).mergeable).isEqualTo(Boolean.TRUE);
  }

  @Test
  public void mergeableOtherBranches() throws Exception {
    String head = getHead(repo(), HEAD).name();
    createBranchWithRevision(BranchNameKey.create(project, "mergeable-other-branch"), head);
    createBranchWithRevision(BranchNameKey.create(project, "ignored"), head);
    PushOneCommit.Result change1 = createChange();
    try (ProjectConfigUpdate u = updateProject(project)) {
      u.getConfig()
          .setBranchOrderSection(
              BranchOrderSection.create(
                  ImmutableList.of("master", "nonexistent", "mergeable-other-branch")));
      u.save();
    }

    MergeableInfo mergeableInfo =
        gApi.changes().id(change1.getChangeId()).current().mergeableOtherBranches();
    assertThat(mergeableInfo.mergeableInto).containsExactly("mergeable-other-branch");
  }

  @Test
  public void files() throws Exception {
    PushOneCommit.Result r = createChange();
    Map<String, FileInfo> files =
        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files();
    assertThat(files.keySet()).containsExactly(FILE_NAME, COMMIT_MSG);
  }

  @Test
  public void filesOnMergeCommitChange() throws Exception {
    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");

    // List files against auto-merge
    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files().keySet())
        .containsExactly(COMMIT_MSG, MERGE_LIST, "foo", "bar");

    // List files against parent 1
    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(1).keySet())
        .containsExactly(COMMIT_MSG, MERGE_LIST, "bar");

    // List files against parent 2
    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(2).keySet())
        .containsExactly(COMMIT_MSG, MERGE_LIST, "foo");
  }

  @Test
  public void filesOnMergeCommitChangeWithInvalidParent() throws Exception {
    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");

    BadRequestException thrown =
        assertThrows(
            BadRequestException.class,
            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(3));
    assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: 3");
    thrown =
        assertThrows(
            BadRequestException.class,
            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(-1));
    assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: -1");
  }

  @Test
  public void listFilesWithInvalidParent() throws Exception {
    PushOneCommit.Result result1 = createChange();
    String changeId = result1.getChangeId();
    PushOneCommit.Result result2 = amendChange(changeId, SUBJECT, "b.txt", "b");
    String revId2 = result2.getCommit().name();

    BadRequestException thrown =
        assertThrows(
            BadRequestException.class, () -> gApi.changes().id(changeId).revision(revId2).files(2));
    assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: 2");

    thrown =
        assertThrows(
            BadRequestException.class,
            () -> gApi.changes().id(changeId).revision(revId2).files(-1));
    assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: -1");
  }

  @Test
  public void listFilesOnDifferentBases() throws Exception {
    RevCommit initialCommit = getHead(repo(), "HEAD");

    PushOneCommit.Result result1 = createChange();
    String changeId = result1.getChangeId();
    PushOneCommit.Result result2 = amendChange(changeId, SUBJECT, "b.txt", "b");
    PushOneCommit.Result result3 = amendChange(changeId, SUBJECT, "c.txt", "c");

    String revId1 = result1.getCommit().name();
    String revId2 = result2.getCommit().name();
    String revId3 = result3.getCommit().name();

    assertThat(gApi.changes().id(changeId).revision(revId1).files(null).keySet())
        .containsExactly(COMMIT_MSG, "a.txt");
    assertThat(gApi.changes().id(changeId).revision(revId2).files(null).keySet())
        .containsExactly(COMMIT_MSG, "a.txt", "b.txt");
    assertThat(gApi.changes().id(changeId).revision(revId3).files(null).keySet())
        .containsExactly(COMMIT_MSG, "a.txt", "b.txt", "c.txt");

    assertThat(gApi.changes().id(changeId).revision(revId2).files(revId1).keySet())
        .containsExactly(COMMIT_MSG, "b.txt");
    assertThat(gApi.changes().id(changeId).revision(revId3).files(revId1).keySet())
        .containsExactly(COMMIT_MSG, "b.txt", "c.txt");
    assertThat(gApi.changes().id(changeId).revision(revId3).files(revId2).keySet())
        .containsExactly(COMMIT_MSG, "c.txt");

    ResourceNotFoundException thrown =
        assertThrows(
            ResourceNotFoundException.class,
            () -> gApi.changes().id(changeId).revision(revId3).files(initialCommit.getName()));
    assertThat(thrown).hasMessageThat().contains(initialCommit.getName());

    String invalidRev = "deadbeef";
    thrown =
        assertThrows(
            ResourceNotFoundException.class,
            () -> gApi.changes().id(changeId).revision(revId3).files(invalidRev));
    assertThat(thrown).hasMessageThat().contains(invalidRev);
  }

  @Test
  public void queryRevisionFiles() throws Exception {
    Map<String, String> files = ImmutableMap.of("file1.txt", "content 1", "file2.txt", "content 2");
    PushOneCommit.Result result =
        pushFactory.create(admin.newIdent(), testRepo, SUBJECT, files).to("refs/for/master");
    result.assertOkStatus();
    String changeId = result.getChangeId();

    assertThat(gApi.changes().id(changeId).current().queryFiles("file1.txt"))
        .containsExactly("file1.txt");
    assertThat(gApi.changes().id(changeId).current().queryFiles("file2.txt"))
        .containsExactly("file2.txt");
    assertThat(gApi.changes().id(changeId).current().queryFiles("file1"))
        .containsExactly("file1.txt");
    assertThat(gApi.changes().id(changeId).current().queryFiles("file2"))
        .containsExactly("file2.txt");
    assertThat(gApi.changes().id(changeId).current().queryFiles("file"))
        .containsExactly("file1.txt", "file2.txt");
    assertThat(gApi.changes().id(changeId).current().queryFiles(""))
        .containsExactly("file1.txt", "file2.txt");
  }

  @Test
  public void description() throws Exception {
    PushOneCommit.Result r = createChange();
    assertDescription(r, "");

    // set description
    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("foo");
    assertDescription(r, "foo");
    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages).message)
        .isEqualTo("Description of patch set 1 set to \"foo\"");

    // update description
    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("bar");
    assertDescription(r, "bar");
    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages).message)
        .isEqualTo("Description of patch set 1 changed to \"bar\"");

    // remove description
    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("");
    assertDescription(r, "");
    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages).message)
        .isEqualTo("Description \"bar\" removed from patch set 1");
  }

  @Test
  public void targetBranch_isSetForEachPatchSet() throws Exception {
    createBranch(BranchNameKey.create(project, "foo"));
    createBranch(BranchNameKey.create(project, "bar"));

    // Upload five patch-sets: patch-set 1 is destined for branch master, patch-set 2 destined for
    // branch foo, patch-set 3 with no change, and patch-set 4 for branch bar and patch-set 5 with
    // no change in branch. Make sure the targetBranch field is set correctly on each revision.

    // PS1 - branch = master
    PushOneCommit.Result r1 = createChange();
    // PS2 - branch = foo
    PushOneCommit.Result r2 = amendChange(r1.getChangeId());
    move(r1.getChangeId(), "foo");
    // PS3 - branch = foo
    PushOneCommit.Result r3 = amendChange(r1.getChangeId(), "refs/for/foo", admin, testRepo);
    // PS4 - branch = bar
    PushOneCommit.Result r4 = amendChange(r1.getChangeId(), "refs/for/foo", admin, testRepo);
    move(r1.getChangeId(), "bar");
    // PS5 - branch = bar
    PushOneCommit.Result r5 = amendChange(r1.getChangeId(), "refs/for/bar", admin, testRepo);

    Map<String, RevisionInfo> revisions = gApi.changes().id(r1.getChangeId()).get().revisions;
    assertThat(revisions).hasSize(5);
    assertThat(revisions.get(r1.getCommit().name()).branch).isEqualTo("refs/heads/master");
    assertThat(revisions.get(r2.getCommit().name()).branch).isEqualTo("refs/heads/foo");
    assertThat(revisions.get(r3.getCommit().name()).branch).isEqualTo("refs/heads/foo");
    assertThat(revisions.get(r4.getCommit().name()).branch).isEqualTo("refs/heads/bar");
    assertThat(revisions.get(r5.getCommit().name()).branch).isEqualTo("refs/heads/bar");
  }

  @Test
  public void setDescriptionNotAllowedWithoutPermission() throws Exception {
    PushOneCommit.Result r = createChange();
    assertDescription(r, "");
    requestScopeOperations.setApiUser(user.id());
    AuthException thrown =
        assertThrows(
            AuthException.class,
            () ->
                gApi.changes()
                    .id(r.getChangeId())
                    .revision(r.getCommit().name())
                    .description("test"));
    assertThat(thrown).hasMessageThat().contains("edit description not permitted");
  }

  @Test
  public void setDescriptionAllowedWithPermission() throws Exception {
    PushOneCommit.Result r = createChange();
    assertDescription(r, "");
    projectOperations
        .project(project)
        .forUpdate()
        .add(allow(Permission.OWNER).ref("refs/heads/master").group(REGISTERED_USERS))
        .update();
    requestScopeOperations.setApiUser(user.id());
    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description("test");
    assertDescription(r, "test");
  }

  private void assertDescription(PushOneCommit.Result r, String expected) throws Exception {
    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).description())
        .isEqualTo(expected);
  }

  @Test
  public void content() throws Exception {
    PushOneCommit.Result r = createChange();
    assertContent(r, FILE_NAME, FILE_CONTENT);
    assertContent(r, COMMIT_MSG, r.getCommit().getFullMessage());
  }

  @Test
  @GerritConfig(name = "change.maxFileSizeDownload", value = "10")
  public void content_maxFileSizeDownload() throws Exception {
    Map<String, String> files =
        ImmutableMap.of("dir/file1.txt", " 9 bytes ", "dir/file2.txt", "11 bytes xx");
    PushOneCommit.Result result =
        pushFactory.create(admin.newIdent(), testRepo, SUBJECT, files).to("refs/for/master");
    result.assertOkStatus();

    // 9 bytes should be fine, because the limit is 10 bytes.
    assertContent(result, "dir/file1.txt", " 9 bytes ");

    // 11 bytes should throw, because the limit is 10 bytes.
    BadRequestException exception =
        assertThrows(
            BadRequestException.class,
            () ->
                gApi.changes()
                    .id(result.getChangeId())
                    .revision(result.getCommit().name())
                    .file("dir/file2.txt")
                    .content());
    assertThat(exception)
        .hasMessageThat()
        .isEqualTo(
            "File too big. File size: 11 bytes. Configured 'maxFileSizeDownload' limit: 10 bytes.");
  }

  @Test
  public void patchsetLevelContentDoesNotExist() throws Exception {
    PushOneCommit.Result change = createChange();
    assertThrows(
        ResourceNotFoundException.class,
        () ->
            gApi.changes()
                .id(change.getChangeId())
                .revision(change.getCommit().name())
                .file(PATCHSET_LEVEL)
                .content());
  }

  @Test
  public void cannotGetContentOfDirectory() throws Exception {
    Map<String, String> files = ImmutableMap.of("dir/file1.txt", "content 1");
    PushOneCommit.Result result =
        pushFactory.create(admin.newIdent(), testRepo, SUBJECT, files).to("refs/for/master");
    result.assertOkStatus();

    assertThrows(
        BadRequestException.class,
        () ->
            gApi.changes()
                .id(result.getChangeId())
                .revision(result.getCommit().name())
                .file("dir")
                .content());
  }

  @Test
  public void contentType() throws Exception {
    PushOneCommit.Result r = createChange();

    String endPoint =
        "/changes/"
            + r.getChangeId()
            + "/revisions/"
            + r.getCommit().name()
            + "/files/"
            + FILE_NAME
            + "/content";
    RestResponse response = adminRestSession.head(endPoint);
    response.assertOK();
    assertThat(response.getContentType()).startsWith("text/plain");
    assertThat(response.hasContent()).isFalse();
  }

  @Test
  public void commit() throws Exception {
    WebLinkInfo expectedPatchSetLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
    PatchSetWebLink patchSetLink =
        new PatchSetWebLink() {
          @Override
          public WebLinkInfo getPatchSetWebLink(
              String projectName, String commit, String commitMessage, String branchName) {
            return expectedPatchSetLinkInfo;
          }
        };
    WebLinkInfo expectedResolveConflictsLinkInfo = new WebLinkInfo("bar", "img", "resolve");
    ResolveConflictsWebLink resolveConflictsLink =
        new ResolveConflictsWebLink() {
          @Override
          public WebLinkInfo getResolveConflictsWebLink(
              String projectName, String commit, String commitMessage, String branchName) {
            return expectedResolveConflictsLinkInfo;
          }
        };
    try (Registration registration =
        extensionRegistry.newRegistration().add(patchSetLink).add(resolveConflictsLink)) {
      PushOneCommit.Result r = createChange();
      RevCommit c = r.getCommit();

      CommitInfo commitInfo = gApi.changes().id(r.getChangeId()).current().commit(false);
      assertThat(commitInfo.commit).isEqualTo(c.name());
      assertPersonIdent(commitInfo.author, c.getAuthorIdent());
      assertPersonIdent(commitInfo.committer, c.getCommitterIdent());
      assertThat(commitInfo.message).isEqualTo(c.getFullMessage());
      assertThat(commitInfo.subject).isEqualTo(c.getShortMessage());
      assertThat(commitInfo.parents).hasSize(1);
      assertThat(Iterables.getOnlyElement(commitInfo.parents).commit)
          .isEqualTo(c.getParent(0).name());
      assertThat(commitInfo.webLinks).isNull();

      commitInfo = gApi.changes().id(r.getChangeId()).current().commit(true);
      assertThat(commitInfo.webLinks).hasSize(1);
      WebLinkInfo patchSetLinkInfo = Iterables.getOnlyElement(commitInfo.webLinks);
      assertThat(patchSetLinkInfo.name).isEqualTo(expectedPatchSetLinkInfo.name);
      assertThat(patchSetLinkInfo.imageUrl).isEqualTo(expectedPatchSetLinkInfo.imageUrl);
      assertThat(patchSetLinkInfo.url).isEqualTo(expectedPatchSetLinkInfo.url);

      assertThat(commitInfo.resolveConflictsWebLinks).hasSize(1);
      WebLinkInfo resolveCommentsLinkInfo =
          Iterables.getOnlyElement(commitInfo.resolveConflictsWebLinks);
      assertThat(resolveCommentsLinkInfo.name).isEqualTo(expectedResolveConflictsLinkInfo.name);
      assertThat(resolveCommentsLinkInfo.imageUrl)
          .isEqualTo(expectedResolveConflictsLinkInfo.imageUrl);
      assertThat(resolveCommentsLinkInfo.url).isEqualTo(expectedResolveConflictsLinkInfo.url);
    }
  }

  // TODO(issue-15508): Migrate timestamp fields in *Info/*Input classes from type Timestamp to
  // Instant
  @SuppressWarnings("JdkObsolete")
  private void assertPersonIdent(GitPerson gitPerson, PersonIdent expectedIdent) {
    assertThat(gitPerson.name).isEqualTo(expectedIdent.getName());
    assertThat(gitPerson.email).isEqualTo(expectedIdent.getEmailAddress());
    assertThat(gitPerson.date.getTime()).isEqualTo(expectedIdent.getWhenAsInstant().toEpochMilli());
    assertThat(gitPerson.tz).isEqualTo(expectedIdent.getTimeZoneOffset());
  }

  private void assertMergeable(String id, boolean expected) throws Exception {
    MergeableInfo m = gApi.changes().id(id).current().mergeable();
    assertThat(m.mergeable).isEqualTo(expected);
    assertThat(m.submitType).isEqualTo(SubmitType.MERGE_IF_NECESSARY);
    assertThat(m.mergeableInto).isNull();
    ChangeInfo c = gApi.changes().id(id).info();
    assertThat(c.mergeable).isEqualTo(expected);
  }

  @Test
  public void drafts() throws Exception {
    PushOneCommit.Result r = createChange();
    DraftInput in = new DraftInput();
    in.line = 1;
    in.message = "nit: trailing whitespace";
    in.path = FILE_NAME;

    DraftApi draftApi =
        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).createDraft(in);
    assertThat(draftApi.get().message).isEqualTo(in.message);
    assertThat(
            gApi.changes()
                .id(r.getChangeId())
                .revision(r.getCommit().name())
                .draft(draftApi.get().id)
                .get()
                .message)
        .isEqualTo(in.message);
    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).drafts())
        .hasSize(1);

    in.message = "good catch!";
    assertThat(
            gApi.changes()
                .id(r.getChangeId())
                .revision(r.getCommit().name())
                .draft(draftApi.get().id)
                .update(in)
                .message)
        .isEqualTo(in.message);

    assertThat(
            gApi.changes()
                .id(r.getChangeId())
                .revision(r.getCommit().name())
                .draft(draftApi.get().id)
                .get()
                .author
                .email)
        .isEqualTo(admin.email());

    draftApi.delete();
    assertThat(gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).drafts())
        .isEmpty();
  }

  @Test
  public void comments() throws Exception {
    PushOneCommit.Result r = createChange();
    CommentInput in = new CommentInput();
    in.line = 1;
    in.message = "nit: trailing whitespace";
    in.path = FILE_NAME;
    ReviewInput reviewInput = new ReviewInput();
    Map<String, List<CommentInput>> comments = new HashMap<>();
    comments.put(FILE_NAME, Collections.singletonList(in));
    reviewInput.comments = comments;
    reviewInput.message = "comment test";
    gApi.changes().id(r.getChangeId()).current().review(reviewInput);

    Map<String, List<CommentInfo>> out =
        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).comments();
    assertThat(out).hasSize(1);
    CommentInfo comment = Iterables.getOnlyElement(out.get(FILE_NAME));
    assertThat(comment.message).isEqualTo(in.message);
    assertThat(comment.author.email).isEqualTo(admin.email());
    assertThat(comment.path).isNull();

    List<CommentInfo> list =
        gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).commentsAsList();
    assertThat(list).hasSize(1);

    CommentInfo comment2 = list.get(0);
    assertThat(comment2.path).isEqualTo(FILE_NAME);
    assertThat(comment2.line).isEqualTo(comment.line);
    assertThat(comment2.message).isEqualTo(comment.message);
    assertThat(comment2.author.email).isEqualTo(comment.author.email);

    assertThat(
            gApi.changes()
                .id(r.getChangeId())
                .revision(r.getCommit().name())
                .comment(comment.id)
                .get()
                .message)
        .isEqualTo(in.message);
  }

  @Test
  public void commentOnNonExistingFile() throws Exception {
    PushOneCommit.Result r1 = createChange();
    PushOneCommit.Result r2 = updateChange(r1, "new content");
    CommentInput in = new CommentInput();
    in.line = 1;
    in.message = "nit: trailing whitespace";
    in.path = "non-existing.txt";
    ReviewInput reviewInput = new ReviewInput();
    Map<String, List<CommentInput>> comments = new HashMap<>();
    comments.put("non-existing.txt", Collections.singletonList(in));
    reviewInput.comments = comments;
    reviewInput.message = "comment test";

    BadRequestException thrown =
        assertThrows(
            BadRequestException.class,
            () -> gApi.changes().id(r2.getChangeId()).revision(1).review(reviewInput));
    assertThat(thrown)
        .hasMessageThat()
        .contains(
            String.format("not found in revision %d,1", r2.getChange().change().getId().get()));
  }

  @Test
  public void patch() throws Exception {
    PushOneCommit.Result r = createChange();
    ChangeApi changeApi = gApi.changes().id(r.getChangeId());
    BinaryResult bin = changeApi.revision(r.getCommit().name()).patch();
    ByteArrayOutputStream os = new ByteArrayOutputStream();
    bin.writeTo(os);
    String res = new String(os.toByteArray(), UTF_8);
    ChangeInfo change = changeApi.get();
    RevisionInfo rev = change.revisions.get(change.currentRevision);
    DateFormat df = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", Locale.US);
    String date = df.format(rev.commit.author.date);
    assertThat(res).isEqualTo(String.format(PATCH, r.getCommit().name(), date, r.getChangeId()));
  }

  @Test
  public void patchWithPath() throws Exception {
    PushOneCommit.Result r = createChange();
    ChangeApi changeApi = gApi.changes().id(r.getChangeId());
    BinaryResult bin = changeApi.revision(r.getCommit().name()).patch(FILE_NAME);
    ByteArrayOutputStream os = new ByteArrayOutputStream();
    bin.writeTo(os);
    String res = new String(os.toByteArray(), UTF_8);
    assertThat(res).isEqualTo(PATCH_FILE_ONLY);

    ResourceNotFoundException thrown =
        assertThrows(
            ResourceNotFoundException.class,
            () -> changeApi.revision(r.getCommit().name()).patch("nonexistent-file"));
    assertThat(thrown).hasMessageThat().contains("File not found: nonexistent-file.");
  }

  @Test
  public void actions() throws Exception {
    PushOneCommit.Result r = createChange();
    assertThat(current(r).actions().keySet())
        .containsExactly("cherrypick", "description", "rebase");

    current(r).review(ReviewInput.approve());
    assertThat(current(r).actions().keySet())
        .containsExactly("submit", "cherrypick", "description", "rebase");

    current(r).submit();
    assertThat(current(r).actions().keySet()).containsExactly("cherrypick");
  }

  @Test
  public void deleteVoteOnNonCurrentPatchSet() throws Exception {
    PushOneCommit.Result r = createChange(); // patch set 1
    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());

    // patch set 2
    amendChange(r.getChangeId());

    // code-review
    requestScopeOperations.setApiUser(user.id());
    recommend(r.getChangeId());

    // check if it's blocked to delete a vote on a non-current patch set.
    requestScopeOperations.setApiUser(admin.id());
    MethodNotAllowedException thrown =
        assertThrows(
            MethodNotAllowedException.class,
            () ->
                gApi.changes()
                    .id(r.getChangeId())
                    .revision(r.getCommit().getName())
                    .reviewer(user.id().toString())
                    .deleteVote(LabelId.CODE_REVIEW));
    assertThat(thrown).hasMessageThat().contains("Cannot access on non-current patch set");
  }

  @Test
  public void deleteVoteOnCurrentPatchSet() throws Exception {
    PushOneCommit.Result r = createChange(); // patch set 1
    gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).review(ReviewInput.approve());

    // patch set 2
    amendChange(r.getChangeId());

    // code-review
    requestScopeOperations.setApiUser(user.id());
    recommend(r.getChangeId());

    requestScopeOperations.setApiUser(admin.id());
    gApi.changes()
        .id(r.getChangeId())
        .current()
        .reviewer(user.id().toString())
        .deleteVote(LabelId.CODE_REVIEW);

    Map<String, Short> m =
        gApi.changes().id(r.getChangeId()).current().reviewer(user.id().toString()).votes();

    assertThat(m).containsExactly(LabelId.CODE_REVIEW, Short.valueOf((short) 0));

    ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
    ChangeMessageInfo message = Iterables.getLast(c.messages);
    assertThat(message.author._accountId).isEqualTo(admin.id().get());
    assertThat(message.message)
        .isEqualTo(
            String.format(
                "Removed Code-Review+1 by %s\n",
                AccountTemplateUtil.getAccountTemplate(user.id())));
    assertThat(getReviewers(c.reviewers.get(ReviewerState.REVIEWER)))
        .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
  }

  @Test
  public void listVotesByRevision() throws Exception {
    // Create patch set 1 and vote on it
    String changeId = createChange().getChangeId();
    ListMultimap<String, ApprovalInfo> votes = gApi.changes().id(changeId).current().votes();
    assertThat(votes).isEmpty();
    recommend(changeId);
    votes = gApi.changes().id(changeId).current().votes();
    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
    List<ApprovalInfo> approvals = votes.get(LabelId.CODE_REVIEW);
    assertThat(approvals).hasSize(1);
    ApprovalInfo approval = approvals.get(0);
    assertThat(approval._accountId).isEqualTo(admin.id().get());
    assertThat(approval.email).isEqualTo(admin.email());
    assertThat(approval.username).isEqualTo(admin.username());

    // Also vote on it with another user
    requestScopeOperations.setApiUser(user.id());
    gApi.changes().id(changeId).current().review(ReviewInput.dislike());

    // Patch set 1 has 2 votes on Code-Review
    requestScopeOperations.setApiUser(admin.id());
    votes = gApi.changes().id(changeId).current().votes();
    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
    approvals = votes.get(LabelId.CODE_REVIEW);
    assertThat(approvals).hasSize(2);
    assertThat(approvals.stream().map(a -> a._accountId))
        .containsExactlyElementsIn(ImmutableList.of(admin.id().get(), user.id().get()));

    // Create a new patch set which does not have any votes
    amendChange(changeId);
    votes = gApi.changes().id(changeId).current().votes();
    assertThat(votes).isEmpty();

    // Votes are still returned for ps 1
    votes = gApi.changes().id(changeId).revision(1).votes();
    assertThat(votes.keySet()).containsExactly(LabelId.CODE_REVIEW);
    approvals = votes.get(LabelId.CODE_REVIEW);
    assertThat(approvals).hasSize(2);
  }

  @Test
  public void uploaderNotAddedAsReviewer() throws Exception {
    PushOneCommit.Result result = createChange();
    amendChangeWithUploader(result, project, user);
    assertThat(result.getChange().reviewers().all()).isEmpty();
  }

  @Test
  public void notificationsOnPushNewPatchset() throws Exception {
    PushOneCommit.Result r = createChange();
    change(r).addReviewer(user.email());
    sender.clear();

    // check that reviewer is notified.
    amendChange(r.getChangeId());
    List<FakeEmailSender.Message> messages = sender.getMessages();
    FakeEmailSender.Message m = Iterables.getOnlyElement(messages);
    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
    assertThat(m.body()).contains("I'd like you to reexamine a change.");
  }

  @Test
  @GerritConfig(name = "change.sendNewPatchsetEmails", value = "false")
  public void notificationsOnPushNewPatchsetNotSentWithSendNewPatchsetEmailsAsFalse()
      throws Exception {
    PushOneCommit.Result r = createChange();
    change(r).addReviewer(user.email());
    sender.clear();

    // check that reviewer is not notified
    amendChange(r.getChangeId());
    assertThat(sender.getMessages()).isEmpty();
  }

  @Test
  @GerritConfig(name = "change.sendNewPatchsetEmails", value = "false")
  public void notificationsOnPushNewPatchsetAlwaysSentToProjectWatchers() throws Exception {
    PushOneCommit.Result r = createChange();
    requestScopeOperations.setApiUser(user.id());
    watch(project.get());
    sender.clear();

    // check that watcher is notified
    amendChange(r.getChangeId());
    List<FakeEmailSender.Message> messages = sender.getMessages();
    FakeEmailSender.Message m = Iterables.getOnlyElement(messages);
    assertThat(m.rcpt()).containsExactly(user.getNameEmail());
    assertThat(m.body()).contains(admin.fullName() + " has uploaded a new patch set (#2).");
  }

  @Test
  public void parentData_notPopulatedIfParentsChangeOptionIsNotSet() throws Exception {
    PushOneCommit.Result r = createChange();
    List<ParentInfo> parentsData =
        getRevisionWithoutParents(r.getChangeId(), /* patchSetNumber= */ 1).parentsData;
    assertThat(parentsData).isNull();
  }

  @Test
  public void parentData_parentIsATargetBranch() throws Exception {
    RevCommit initialCommit = getHead(repo(), "HEAD");
    PushOneCommit.Result r = createChange();
    List<ParentInfo> parentsData =
        getRevisionWithParents(r.getChangeId(), /* patchSetNumber= */ 1).parentsData;
    assertThat(parentsData).hasSize(1);
    assertParentIsInTargetBranch(
        parentsData.get(0), "refs/heads/master", initialCommit.getId().name());
  }

  @Test
  public void parentData_parentIsATargetBranch_changeMoved() throws Exception {
    createBranch(BranchNameKey.create(project, "foo"));

    // PS1 was set to be merged in refs/heads/master
    RevCommit initialCommit = getHead(repo(), "HEAD");
    PushOneCommit.Result r = createChange();

    // Create PS2, and set target branch to refs/heads/foo
    amendChange(r.getChangeId());
    move(r.getChangeId(), "foo");

    // PS1 is based on refs/heads/master
    List<ParentInfo> parentsData =
        getRevisionWithParents(r.getChangeId(), /* patchSetNumber= */ 1).parentsData;
    assertThat(parentsData).hasSize(1);
    assertParentIsInTargetBranch(parentsData.get(0), "refs/heads/master", initialCommit.name());

    // PS2 is based on refs/heads/foo
    parentsData = getRevisionWithParents(r.getChangeId(), /* patchSetNumber= */ 2).parentsData;
    assertThat(parentsData).hasSize(1);
    assertParentIsInTargetBranch(parentsData.get(0), "refs/heads/foo", initialCommit.name());
  }

  @Test
  public void parentData_parentIsAPendingChange() throws Exception {
    PushOneCommit.Result r1 = createChange();
    PushOneCommit.Result r2 = createChange();

    List<ParentInfo> parentsData =
        getRevisionWithParents(r2.getChangeId(), /* patchSetNumber= */ 1).parentsData;
    assertThat(parentsData).hasSize(1);
    assertParentIsChange(
        parentsData.get(0),
        r1.getCommit().getId(),
        r1.getChangeId(),
        r1.getChange().change().getChangeId(),
        /* patchSetNumber= */ 1,
        /* parentChangeStatus= */ "NEW",
        /* isParentCommitMergedInTargetBranch= */ false);
  }

  @Test
  public void parentData_parentIsANonLastPatchSet_parentIsPending() throws Exception {
    PushOneCommit.Result r1 = createChange();
    PushOneCommit.Result r2 = createChange();

    // Create PS2 of the first change
    testRepo.reset(r1.getCommit());
    amendChange(r1.getChangeId());
    assertThat(gApi.changes().id(r1.getChangeId()).get().revisions).hasSize(2);

    List<ParentInfo> parentsData =
        getRevisionWithParents(r2.getChangeId(), /* patchSetNumber= */ 1).parentsData;
    assertThat(parentsData).hasSize(1);
    assertParentIsChange(
        parentsData.get(0),
        r1.getCommit().getId(),
        r1.getChangeId(),
        r1.getChange().change().getChangeId(),
        /* patchSetNumber= */ 1,
        /* parentChangeStatus= */ "NEW",
        /* isParentCommitMergedInTargetBranch= */ false);
  }

  @Test
  public void parentData_parentIsALastPatchSet_parentIsPending() throws Exception {
    // Create two patch-sets of change 1, and base change 2 on the second PS of change 1.
    PushOneCommit.Result r1 = createChange();
    PushOneCommit.Result r12 = amendChange(r1.getChangeId());
    assertThat(gApi.changes().id(r1.getChangeId()).get().revisions).hasSize(2);
    PushOneCommit.Result r2 = createChange();

    List<ParentInfo> parentsData =
        getRevisionWithParents(r2.getChangeId(), /* patchSetNumber= */ 1).parentsData;
    assertThat(parentsData).hasSize(1);
    assertParentIsChange(
        parentsData.get(0),
        r12.getCommit().getId(),
        r1.getChangeId(),
        r1.getChange().change().getChangeId(),
        /* patchSetNumber= */ 2,
        /* parentChangeStatus= */ "NEW",
        /* isParentCommitMergedInTargetBranch= */ false);
  }

  @Test
  public void parentData_parentIsANonLastPatchSet_parentIsMerged() throws Exception {
    PushOneCommit.Result r1 = createChange();
    PushOneCommit.Result r2 = createChange();

    // Create PS2 of the first change, and merge it.
    testRepo.reset(r1.getCommit());
    amendChange(r1.getChangeId());
    approve(r1.getChangeId());
    gApi.changes().id(r1.getChangeId()).current().submit();

    List<ParentInfo> parentsData =
        getRevisionWithParents(r2.getChangeId(), /* patchSetNumber= */ 1).parentsData;
    assertThat(parentsData).hasSize(1);
    assertParentIsChange(
        parentsData.get(0),
        r1.getCommit().getId(),
        r1.getChangeId(),
        r1.getChange().change().getChangeId(),
        /* patchSetNumber= */ 1,
        /* parentChangeStatus= */ "MERGED",
        /* isParentCommitMergedInTargetBranch= */ false);
  }

  @Test
  public void parentData_parentIsLastPatchSet_parentIsMerged_fastForwardSubmitStrategy()
      throws Exception {
    updateSubmitType(project, SubmitType.FAST_FORWARD_ONLY);

    PushOneCommit.Result r11 = createChange();
    PushOneCommit.Result r12 = amendChange(r11.getChangeId());
    PushOneCommit.Result r2 = createChange();

    // Merge the first change
    approve(r11.getChangeId());
    gApi.changes().id(r11.getChangeId()).current().submit();

    List<ParentInfo> parentsData =
        getRevisionWithParents(r2.getChangeId(), /* patchSetNumber= */ 1).parentsData;
    assertThat(parentsData).hasSize(1);

    // The second change is based on the last patch-set of the first change. But since the first
    // change is merged and the submit strategy is fast-forward, PS2 of change 1 is now included
    // in the target branch, hence the parent data reflects that.
    assertParentIsInTargetBranch(
        parentsData.get(0), "refs/heads/master", r12.getCommit().getId().name());
    assertParentIsChange(
        parentsData.get(0),
        r12.getCommit().getId(),
        r11.getChangeId(),
        r11.getChange().change().getChangeId(),
        /* patchSetNumber= */ 2,
        /* parentChangeStatus= */ "MERGED",
        /* isParentCommitMergedInTargetBranch= */ true);
  }

  @Test
  public void parentData_parentIsLastPatchSet_parentIsMerged_rebaseAlwaysSubmitStrategy()
      throws Exception {
    updateSubmitType(project, SubmitType.REBASE_ALWAYS);

    PushOneCommit.Result r11 = createChange();
    PushOneCommit.Result r12 = amendChange(r11.getChangeId());
    PushOneCommit.Result r2 = createChange();

    // Merge the first change
    approve(r11.getChangeId());
    gApi.changes().id(r11.getChangeId()).current().submit();

    List<ParentInfo> parentsData =
        getRevisionWithParents(r2.getChangeId(), /* patchSetNumber= */ 1).parentsData;
    assertThat(parentsData).hasSize(1);

    // The second change is based on the last patch-set of the first change. But since the first
    // change is merged and the submit strategy is rebase-always, PS2 of change 1 is not included
    // in the target branch and there is another commit that got rebased in the target branch. The
    // parent of change 2 is patch-set 2 of change 1.
    assertParentIsChange(
        parentsData.get(0),
        r12.getCommit().getId(),
        r11.getChangeId(),
        r11.getChange().change().getChangeId(),
        /* patchSetNumber= */ 2,
        /* parentChangeStatus= */ "MERGED",
        /* isParentCommitMergedInTargetBranch= */ false);
  }

  private RevisionInfo getRevisionWithParents(String changeId, int patchSetNumber)
      throws Exception {
    return getRevision(
        changeId,
        patchSetNumber,
        EnumSet.complementOf(EnumSet.of(ListChangesOption.CHECK, ListChangesOption.SKIP_DIFFSTAT)));
  }

  private RevisionInfo getRevisionWithoutParents(String changeId, int patchSetNumber)
      throws Exception {
    return getRevision(
        changeId,
        patchSetNumber,
        EnumSet.complementOf(
            EnumSet.of(
                ListChangesOption.PARENTS,
                ListChangesOption.CHECK,
                ListChangesOption.SKIP_DIFFSTAT)));
  }

  private RevisionInfo getRevision(
      String changeId, int patchSetNumber, EnumSet<ListChangesOption> options) throws Exception {
    ChangeInfo changeInfo = gApi.changes().id(changeId).get(options);
    return changeInfo.revisions.values().stream()
        .filter(revision -> revision._number == patchSetNumber)
        .findAny()
        .get();
  }

  private void updateSubmitType(Project.NameKey project, SubmitType submitType) throws Exception {
    ConfigInput input = new ConfigInput();
    input.submitType = submitType;
    gApi.projects().name(project.get()).config(input);
  }

  private void assertParentIsInTargetBranch(ParentInfo info, String branchName, String commitId) {
    assertThat(info.branchName).isEqualTo(branchName);
    assertThat(info.commitId).isEqualTo(commitId);
    assertThat(info.isMergedInTargetBranch).isTrue();
  }

  private void assertParentIsChange(
      ParentInfo info,
      ObjectId parentCommitId,
      String changeId,
      Integer changeNumber,
      Integer patchSetNumber,
      String parentChangeStatus,
      boolean isParentCommitMergedInTargetBranch) {
    assertThat(info.commitId).isEqualTo(parentCommitId.name());
    assertThat(info.changeStatus).isEqualTo(parentChangeStatus);
    assertThat(info.changeNumber).isEqualTo(changeNumber);
    assertThat(info.changeId).isEqualTo(changeId);
    assertThat(info.patchSetNumber).isEqualTo(patchSetNumber);
    assertThat(info.isMergedInTargetBranch).isEqualTo(isParentCommitMergedInTargetBranch);
  }

  private static void assertCherryPickResult(
      ChangeInfo changeInfo, CherryPickInput input, String srcChangeId) {
    assertThat(changeInfo.changeId).isEqualTo(srcChangeId);
    assertThat(changeInfo.revisions.keySet()).containsExactly(changeInfo.currentRevision);
    RevisionInfo revisionInfo = changeInfo.revisions.get(changeInfo.currentRevision);
    assertThat(revisionInfo.commit.message).isEqualTo(input.message);
    assertThat(revisionInfo.commit.parents).hasSize(1);
    assertThat(revisionInfo.commit.parents.get(0).commit).isEqualTo(input.base);
  }

  private void move(String changeId, String destination) throws Exception {
    gApi.changes().id(changeId).move(destination);
  }

  private PushOneCommit.Result updateChange(PushOneCommit.Result r, String content)
      throws Exception {
    PushOneCommit push =
        pushFactory.create(
            admin.newIdent(), testRepo, "test commit", "a.txt", content, r.getChangeId());
    return push.to("refs/for/master");
  }

  private RevisionApi current(PushOneCommit.Result r) throws Exception {
    return gApi.changes().id(r.getChangeId()).current();
  }

  private PushOneCommit.Result createCherryPickableMerge(
      String parent1FileName, String parent2FileName) throws Exception {
    RevCommit initialCommit = getHead(repo(), "HEAD");

    String branchAName = "branchA";
    createBranch(BranchNameKey.create(project, branchAName));
    String branchBName = "branchB";
    createBranch(BranchNameKey.create(project, branchBName));

    PushOneCommit.Result changeAResult =
        pushFactory
            .create(admin.newIdent(), testRepo, "change a", parent1FileName, "Content of a")
            .to("refs/for/" + branchAName);

    testRepo.reset(initialCommit);
    PushOneCommit.Result changeBResult =
        pushFactory
            .create(admin.newIdent(), testRepo, "change b", parent2FileName, "Content of b")
            .to("refs/for/" + branchBName);

    PushOneCommit pushableMergeCommit =
        pushFactory.create(
            admin.newIdent(),
            testRepo,
            "merge",
            ImmutableMap.of(parent1FileName, "Content of a", parent2FileName, "Content of b"));
    pushableMergeCommit.setParents(
        ImmutableList.of(changeAResult.getCommit(), changeBResult.getCommit()));
    PushOneCommit.Result mergeChangeResult = pushableMergeCommit.to("refs/for/" + branchAName);
    mergeChangeResult.assertOkStatus();
    return mergeChangeResult;
  }

  private ApprovalInfo getApproval(String changeId, String label) throws Exception {
    ChangeInfo info = gApi.changes().id(changeId).get(DETAILED_LABELS);
    LabelInfo li = info.labels.get(label);
    assertThat(li).isNotNull();
    int accountId = atrScope.get().getUser().getAccountId().get();
    return li.all.stream().filter(a -> a._accountId == accountId).findFirst().get();
  }

  private static Iterable<Account.Id> getReviewers(Collection<AccountInfo> r) {
    return Iterables.transform(r, a -> Account.id(a._accountId));
  }

  private static class TestCommitValidationListener implements CommitValidationListener {
    public CommitReceivedEvent receiveEvent;

    @Override
    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
        throws CommitValidationException {
      this.receiveEvent = receiveEvent;
      return ImmutableList.of();
    }
  }
}
